Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 92c1e50c14ef6121102b8426b534f294c4e8f654


Parents : b231c68
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-07T05:16:51-05:00

feat(mutation): implement mutation testing framework with mutmut and MeshMut integration, including backend and frontend tasks, configuration, and reporting

Changes
Diff

diff --git a/.dockerignore b/.dockerignore
index 53f90ce9..e34e861c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -106,8 +106,14 @@ coverage/
coverage-electron/
.pytest_cache/
htmlcov/
-.mutmut-cache
+
+# Mutation testing artifacts
+.mutmut-cache/
+mutants/
mutmut-stats/
+mutmut-cicd-stats.json
+reports/mutation/
+.stryker-tmp/
test-results/
playwright-report/

diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml
new file mode 100644
index 00000000..37170b92
--- /dev/null
+++ b/.github/workflows/mutation.yml
@@ -0,0 +1,81 @@
+# Optional mutation testing (manual or weekly). Not part of primary CI.
+#
+# Pinned first-party actions (bump tag and SHA together when upgrading):
+# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
+
+name: Mutation testing
+
+on:
+ workflow_dispatch:
+ inputs:
+ backend:
+ description: Run backend mutmut
+ type: boolean
+ default: true
+ frontend:
+ description: Run frontend MeshMut
+ type: boolean
+ default: true
+ min_score:
+ description: Minimum mutation score percentage (0 to skip gate)
+ type: string
+ default: "50"
+ schedule:
+ - cron: "0 6 * * 0"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: mutation-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ PYTHON_VERSION: "3.14"
+ NODE_VERSION: "24"
+ UV_VERSION: "0.11.15"
+ PNPM_VERSION: "11.1.2"
+
+jobs:
+ mutation:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ steps:
+ - name: Checkout
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+
+ - name: Set up development environment
+ uses: ./.github/actions/setup-dev-environment
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ uv-version: ${{ env.UV_VERSION }}
+ node-version: ${{ env.NODE_VERSION }}
+ pnpm-version: ${{ env.PNPM_VERSION }}
+
+ - name: Backend mutation (mutmut)
+ if: ${{ github.event_name == 'schedule' || inputs.backend != false }}
+ env:
+ MUTMUT_TARGET: meshchatx.src.backend.meshchat_utils*
+ MUTMUT_MIN_SCORE: ${{ github.event_name == 'schedule' && '50' || github.event.inputs.min_score }}
+ run: bash scripts/ci/mutation-backend.sh
+
+ - name: Frontend mutation (MeshMut sample)
+ if: ${{ github.event_name == 'schedule' || inputs.frontend != false }}
+ run: |
+ set -euo pipefail
+ node scripts/mutation/run.mjs \
+ --source meshchatx/src/frontend/js/rnode/Capabilities.js \
+ --source meshchatx/src/frontend/js/mapLinkUtils.js \
+ --max-per-file 30 \
+ --min-score ${{ github.event_name == 'schedule' && '50' || github.event.inputs.min_score }}
+
+ - name: Upload mutation reports
+ if: always()
+ uses: actions/upload-artifact@v5.0.0
+ with:
+ name: mutation-reports
+ path: |
+ reports/mutation/
+ mutmut-cicd-stats.json
+ if-no-files-found: ignore

diff --git a/.gitignore b/.gitignore
index d78512a5..d336476a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,8 +114,14 @@ coverage/
coverage-electron/
.pytest_cache/
htmlcov/
-.mutmut-cache
+
+# Mutation testing artifacts
+.mutmut-cache/
+mutants/
mutmut-stats/
+mutmut-cicd-stats.json
+reports/mutation/
+.stryker-tmp/
test-results/
playwright-report/

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6684128..cc940afb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file.
- **Dependencies**: Added **wasmtime** for backend WASM plugin execution.
- **Plugins**: `--disable-plugins` CLI flag and `MESHCHAT_DISABLE_PLUGINS` environment variable to disable the plugin system entirely at runtime.
- **Vendored LXMFy**: Refreshed `vendor/lxmfy` to upstream **1.6.5** (`d92cfe0`) — Landlock LSM sandbox for bot processes and external cogs, propagation-node init fix, cog permission fix, and dependency alignment with RNS 1.3.5+ / LXMF 1.0.1+.
+- **Mutation testing**: Backend uses **mutmut** (`task test:mutation:backend`); frontend uses in-repo **MeshMut** (`task test:mutation:frontend`) with regex-based mutators and Vitest for pure JS modules. Optional `mutation.yml` workflow for manual or scheduled runs.
### Fixed

diff --git a/Taskfile.yml b/Taskfile.yml
index 974ae48c..1cabb423 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -244,9 +244,25 @@ tasks:
- uv run pytest tests/backend/test_performance_hotpaths.py tests/backend/test_performance_bottlenecks.py
test:mutation:
- desc: Mutation testing (mutmut; slow; optional; default targets meshchat_utils)
+ desc: Mutation testing (backend mutmut + frontend MeshMut; slow; optional)
cmds:
- - uv run mutmut run "meshchatx.src.backend.meshchat_utils*"
+ - task test:mutation:backend
+ - task test:mutation:frontend
+
+ test:mutation:backend:
+ desc: Backend mutation testing with mutmut (default meshchat_utils)
+ cmds:
+ - bash scripts/ci/mutation-backend.sh
+
+ test:mutation:frontend:
+ desc: Frontend mutation testing with in-repo MeshMut
+ cmds:
+ - bash scripts/ci/mutation-frontend.sh
+
+ test:mutation:frontend:sample:
+ desc: Quick MeshMut sample on Capabilities.js (capped mutants)
+ cmds:
+ - node scripts/mutation/run.mjs --source meshchatx/src/frontend/js/rnode/Capabilities.js --max-per-file 20
test:frontend:
aliases: [test:fe]

diff --git a/package.json b/package.json
index d22dba06..9774e84e 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install chromium",
+ "test:mutation:frontend": "node scripts/mutation/run.mjs",
+ "test:mutation:frontend:sample": "node scripts/mutation/run.mjs --source meshchatx/src/frontend/js/rnode/Capabilities.js --max-per-file 20",
"electron-postinstall": "electron-builder install-app-deps && node scripts/ensure-micron-parser-package.js && node scripts/patch-electron-builder-fs.cjs && node scripts/patch-electron-installer-common.cjs",
"electron": "pnpm run electron-postinstall && pnpm run build && electron .",
"dist": "pnpm run electron-postinstall && pnpm run build && electron-builder --publish=never",

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6747754e..9e88377e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,8 @@ settings:
overrides:
'@electron/get': 3.1.0
- ajv: '>=6.14.0'
+ eslint>ajv: 6.14.0
+ '@eslint/eslintrc>ajv': 6.14.0
app-builder-lib>ajv: ^8.18.0
ansi-regex: '>=6.0.1'
strip-ansi: 6.0.1
@@ -4546,7 +4547,7 @@ snapshots:
'@peculiar/json-schema@1.1.12':
dependencies:
- tslib: 2.8.1
+ tslib: 2.4.0
'@peculiar/utils@2.0.3':
dependencies:
@@ -6319,7 +6320,7 @@ snapshots:
is-weakset@2.0.4:
dependencies:
call-bound: 1.0.4
- get-intrinsic: 1.3.0
+ get-intrinsic: 1.2.6
isarray@0.0.1: {}
@@ -6361,7 +6362,7 @@ snapshots:
jake@10.8.5:
dependencies:
async: 3.2.6
- chalk: 4.1.2
+ chalk: 4.1.1
filelist: 1.0.1
minimatch: 10.2.3
@@ -7231,14 +7232,14 @@ snapshots:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
- get-intrinsic: 1.3.0
+ get-intrinsic: 1.2.6
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
- get-intrinsic: 1.3.0
+ get-intrinsic: 1.2.6
object-inspect: 1.13.4
side-channel-map: 1.0.1
@@ -7488,8 +7489,7 @@ snapshots:
dependencies:
utf8-byte-length: 1.0.5
- tslib@2.4.0:
- optional: true
+ tslib@2.4.0: {}
tslib@2.8.1: {}

diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e8ff909b..2ea0ebb5 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -16,7 +16,8 @@ allowBuilds:
overrides:
"@electron/get": "3.1.0"
- ajv: ">=6.14.0"
+ eslint>ajv: "6.14.0"
+ "@eslint/eslintrc>ajv": "6.14.0"
app-builder-lib>ajv: ^8.18.0
ansi-regex: ">=6.0.1"
strip-ansi: "6.0.1"

diff --git a/scripts/ci/mutation-backend.sh b/scripts/ci/mutation-backend.sh
new file mode 100755
index 00000000..88518e88
--- /dev/null
+++ b/scripts/ci/mutation-backend.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+
+TARGET="${MUTMUT_TARGET:-meshchatx.src.backend.meshchat_utils*}"
+THRESHOLD="${MUTMUT_MIN_SCORE:-}"
+STATS_FILE="${MUTMUT_STATS_FILE:-mutmut-cicd-stats.json}"
+
+echo "Running mutmut on: ${TARGET}"
+
+uv run mutmut run "${TARGET}"
+uv run mutmut export-cicd-stats > "${STATS_FILE}"
+
+if [[ -n "${THRESHOLD}" ]]; then
+ uv run python scripts/ci/mutation-score-check.py \
+ --mutmut-stats "${STATS_FILE}" \
+ --min-score "${THRESHOLD}"
+fi

diff --git a/scripts/ci/mutation-frontend.sh b/scripts/ci/mutation-frontend.sh
new file mode 100755
index 00000000..805bc601
--- /dev/null
+++ b/scripts/ci/mutation-frontend.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+
+echo "Running MeshMut mutation testing (frontend)"
+
+node scripts/mutation/run.mjs "$@"

diff --git a/scripts/ci/mutation-score-check.py b/scripts/ci/mutation-score-check.py
new file mode 100644
index 00000000..24319106
--- /dev/null
+++ b/scripts/ci/mutation-score-check.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Check mutation score thresholds for mutmut or MeshMut reports."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--min-score",
+ type=float,
+ required=True,
+ help="Minimum acceptable mutation score percentage (0-100)",
+ )
+ parser.add_argument(
+ "--mutmut-stats",
+ type=Path,
+ help="Path to mutmut export-cicd-stats JSON file",
+ )
+ parser.add_argument(
+ "--meshmut-report",
+ type=Path,
+ help="Path to MeshMut JSON report",
+ )
+ return parser.parse_args()
+
+
+def score_from_mutmut(payload: dict) -> float | None:
+ killed = int(payload.get("killed", 0))
+ survived = int(payload.get("survived", 0))
+ timeout = int(payload.get("timeout", 0))
+ suspicious = int(payload.get("suspicious", 0))
+ evaluated = killed + survived + timeout + suspicious
+ if evaluated == 0:
+ return None
+ return (killed / evaluated) * 100.0
+
+
+def score_from_meshmut(payload: dict) -> float | None:
+ summary = payload.get("summary") or {}
+ score = summary.get("score")
+ if score is not None:
+ return float(score)
+ killed = int(summary.get("killed", 0))
+ survived = int(summary.get("survived", 0))
+ evaluated = killed + survived
+ if evaluated == 0:
+ return None
+ return (killed / evaluated) * 100.0
+
+
+def main() -> int:
+ args = parse_args()
+
+ if not args.mutmut_stats and not args.meshmut_report:
+ print("Provide --mutmut-stats and/or --meshmut-report", file=sys.stderr)
+ return 1
+
+ exit_code = 0
+
+ if args.mutmut_stats:
+ if not args.mutmut_stats.is_file():
+ print(f"Missing mutmut stats: {args.mutmut_stats}", file=sys.stderr)
+ return 1
+ score = score_from_mutmut(
+ json.loads(args.mutmut_stats.read_text(encoding="utf-8"))
+ )
+ if score is None:
+ print("No scored mutmut results.", file=sys.stderr)
+ return 1
+ print(f"Backend mutation score: {score:.1f}% (minimum: {args.min_score:.1f}%)")
+ if score < args.min_score:
+ exit_code = 1
+
+ if args.meshmut_report:
+ if not args.meshmut_report.is_file():
+ print(f"Missing MeshMut report: {args.meshmut_report}", file=sys.stderr)
+ return 1
+ score = score_from_meshmut(
+ json.loads(args.meshmut_report.read_text(encoding="utf-8"))
+ )
+ if score is None:
+ print("No scored MeshMut results.", file=sys.stderr)
+ return 1
+ print(f"Frontend mutation score: {score:.1f}% (minimum: {args.min_score:.1f}%)")
+ if score < args.min_score:
+ exit_code = 1
+
+ if exit_code != 0:
+ print("Mutation score below threshold.", file=sys.stderr)
+
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())

diff --git a/scripts/mutation/config.mjs b/scripts/mutation/config.mjs
new file mode 100644
index 00000000..1fbd0cfc
--- /dev/null
+++ b/scripts/mutation/config.mjs
@@ -0,0 +1,38 @@
+/**
+ * Default frontend mutation targets and their Vitest files.
+ */
+
+/** @typedef {{ source: string, tests: string[] }} MutationTarget */
+
+/** @type {MutationTarget[]} */
+export const DEFAULT_FRONTEND_TARGETS = [
+ {
+ source: "meshchatx/src/frontend/js/rnode/Capabilities.js",
+ tests: ["tests/frontend/RNodeCapabilities.test.js"],
+ },
+ {
+ source: "meshchatx/src/frontend/js/mapLinkUtils.js",
+ tests: ["tests/frontend/mapLinkUtils.test.js", "tests/frontend/mapLinkUtils.security.test.js"],
+ },
+ {
+ source: "meshchatx/src/frontend/js/clipboardUtils.js",
+ tests: ["tests/frontend/clipboardUtils.test.js"],
+ },
+ {
+ source: "meshchatx/src/frontend/js/LinkUtils.js",
+ tests: ["tests/frontend/LinkUtils.test.js"],
+ },
+ {
+ source: "meshchatx/src/frontend/js/reticulumPathfinding.js",
+ tests: ["tests/frontend/reticulumPathfinding.test.js"],
+ },
+];
+
+/**
+ * @param {string} sourcePath
+ * @returns {MutationTarget | undefined}
+ */
+export function findTarget(sourcePath, targets = DEFAULT_FRONTEND_TARGETS) {
+ const normalized = sourcePath.replace(/\\/g, "/");
+ return targets.find((target) => target.source === normalized);
+}

diff --git a/scripts/mutation/mutators.mjs b/scripts/mutation/mutators.mjs
new file mode 100644
index 00000000..50e9f26a
--- /dev/null
+++ b/scripts/mutation/mutators.mjs
@@ -0,0 +1,171 @@
+/**
+ * Regex-based mutation operators for JavaScript source files.
+ * Each operator replaces a single match occurrence per mutant.
+ */
+
+/** @typedef {{ name: string, pattern: RegExp, replace: string | ((match: string) => string) }} MutationOperator */
+
+/** @type {MutationOperator[]} */
+export const MUTATION_OPERATORS = [
+ { name: "strict_eq_flip", pattern: /===/g, replace: "!==" },
+ { name: "strict_ne_flip", pattern: /!==/g, replace: "===" },
+ { name: "loose_eq_flip", pattern: /(?<![=!])==(?!=)/g, replace: "!=" },
+ { name: "loose_ne_flip", pattern: /(?<![=!])!=(?!=)/g, replace: "==" },
+ { name: "logical_and_or", pattern: /&&/g, replace: "||" },
+ { name: "logical_or_and", pattern: /\|\|/g, replace: "&&" },
+ { name: "gt_gte", pattern: /(?<![=<>!])>(?!=)/g, replace: ">=" },
+ { name: "lt_lte", pattern: /(?<![=<>!])<(?!=)/g, replace: "<=" },
+ { name: "gte_gt", pattern: />=/g, replace: ">" },
+ { name: "lte_lt", pattern: /<=/g, replace: "<" },
+ { name: "true_false", pattern: /\btrue\b/g, replace: "false" },
+ { name: "false_true", pattern: /\bfalse\b/g, replace: "true" },
+ { name: "null_undefined", pattern: /\bnull\b/g, replace: "undefined" },
+ { name: "return_empty_array", pattern: /\breturn\s+\[\]/g, replace: "return [1]" },
+ { name: "return_empty_object", pattern: /\breturn\s+\{\}/g, replace: "return { mutated: true }" },
+ { name: "plus_minus", pattern: /(?<=[\d)\]])\+(?=\d)/g, replace: "-" },
+ { name: "minus_plus", pattern: /(?<=[\d)\]])-(?=\d)/g, replace: "+" },
+];
+
+/**
+ * @param {string} source
+ * @param {string} filePath
+ * @returns {Array<{ id: string, operator: string, line: number, column: number, original: string, mutated: string, content: string }>}
+ */
+export function generateMutants(source, filePath) {
+ /** @type {ReturnType<typeof generateMutants>} */
+ const mutants = [];
+
+ for (const operator of MUTATION_OPERATORS) {
+ const pattern = operator.pattern;
+ pattern.lastIndex = 0;
+ let match;
+ while ((match = pattern.exec(source)) !== null) {
+ if (isInsideCommentOrString(source, match.index)) {
+ continue;
+ }
+
+ const replacement = typeof operator.replace === "function" ? operator.replace(match[0]) : operator.replace;
+ const content = source.slice(0, match.index) + replacement + source.slice(match.index + match[0].length);
+ if (content === source) {
+ continue;
+ }
+
+ const { line, column } = offsetToLineColumn(source, match.index);
+ mutants.push({
+ id: `${filePath}::${operator.name}::${match.index}`,
+ operator: operator.name,
+ line,
+ column,
+ original: match[0],
+ mutated: replacement,
+ content,
+ });
+ }
+ pattern.lastIndex = 0;
+ }
+
+ return mutants;
+}
+
+/**
+ * @param {string} source
+ * @param {number} offset
+ */
+function isInsideCommentOrString(source, offset) {
+ let inSingle = false;
+ let inDouble = false;
+ let inTemplate = false;
+ let inLineComment = false;
+ let inBlockComment = false;
+
+ for (let i = 0; i < offset; i += 1) {
+ const ch = source[i];
+ const next = source[i + 1];
+
+ if (inLineComment) {
+ if (ch === "\n") {
+ inLineComment = false;
+ }
+ continue;
+ }
+ if (inBlockComment) {
+ if (ch === "*" && next === "/") {
+ inBlockComment = false;
+ i += 1;
+ }
+ continue;
+ }
+ if (inSingle) {
+ if (ch === "\\") {
+ i += 1;
+ continue;
+ }
+ if (ch === "'") {
+ inSingle = false;
+ }
+ continue;
+ }
+ if (inDouble) {
+ if (ch === "\\") {
+ i += 1;
+ continue;
+ }
+ if (ch === '"') {
+ inDouble = false;
+ }
+ continue;
+ }
+ if (inTemplate) {
+ if (ch === "\\") {
+ i += 1;
+ continue;
+ }
+ if (ch === "`") {
+ inTemplate = false;
+ }
+ continue;
+ }
+
+ if (ch === "/" && next === "/") {
+ inLineComment = true;
+ i += 1;
+ continue;
+ }
+ if (ch === "/" && next === "*") {
+ inBlockComment = true;
+ i += 1;
+ continue;
+ }
+ if (ch === "'") {
+ inSingle = true;
+ continue;
+ }
+ if (ch === '"') {
+ inDouble = true;
+ continue;
+ }
+ if (ch === "`") {
+ inTemplate = true;
+ }
+ }
+
+ return inSingle || inDouble || inTemplate || inLineComment || inBlockComment;
+}
+
+/**
+ * @param {string} source
+ * @param {number} offset
+ */
+function offsetToLineColumn(source, offset) {
+ let line = 1;
+ let column = 1;
+ for (let i = 0; i < offset; i += 1) {
+ if (source[i] === "\n") {
+ line += 1;
+ column = 1;
+ } else {
+ column += 1;
+ }
+ }
+ return { line, column };
+}

diff --git a/scripts/mutation/run.mjs b/scripts/mutation/run.mjs
new file mode 100755
index 00000000..d5135c69
--- /dev/null
+++ b/scripts/mutation/run.mjs
@@ -0,0 +1,240 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+import { DEFAULT_FRONTEND_TARGETS } from "./config.mjs";
+import { generateMutants } from "./mutators.mjs";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "../..");
+
+/**
+ * @typedef {"killed" | "survived" | "error" | "skipped"} MutantStatus
+ */
+
+/**
+ * @typedef {{ id: string, source: string, operator: string, line: number, column: number, status: MutantStatus, detail?: string }} MutantResult
+ */
+
+function parseArgs(argv) {
+ const options = {
+ targets: [...DEFAULT_FRONTEND_TARGETS],
+ minScore: null,
+ maxMutantsPerFile: null,
+ reportPath: "reports/mutation/meshmut-report.json",
+ dryRun: false,
+ explicitSources: false,
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--min-score") {
+ options.minScore = Number(argv[++i]);
+ } else if (arg === "--max-per-file") {
+ options.maxMutantsPerFile = Number(argv[++i]);
+ } else if (arg === "--report") {
+ options.reportPath = argv[++i];
+ } else if (arg === "--source") {
+ const source = argv[++i];
+ const existing = DEFAULT_FRONTEND_TARGETS.find((target) => target.source === source);
+ if (!existing) {
+ throw new Error(`Unknown mutation source: ${source}`);
+ }
+ if (!options.explicitSources) {
+ options.targets = [];
+ options.explicitSources = true;
+ }
+ options.targets.push(existing);
+ } else if (arg === "--dry-run") {
+ options.dryRun = true;
+ } else if (arg === "--help" || arg === "-h") {
+ printHelp();
+ process.exit(0);
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+
+ return options;
+}
+
+function printHelp() {
+ process.stdout.write(`MeshMut — in-repo JavaScript mutation testing
+
+Usage:
+ node scripts/mutation/run.mjs [options]
+
+Options:
+ --source <path> Mutate a single configured source file
+ --min-score <pct> Fail when mutation score is below threshold
+ --max-per-file <n> Cap mutants generated per source file
+ --report <path> JSON report output path (default: reports/mutation/meshmut-report.json)
+ --dry-run List mutants without executing tests
+ --help Show this help
+`);
+}
+
+/**
+ * @param {string[]} testFiles
+ */
+function runVitest(testFiles) {
+ const args = ["exec", "vitest", "run", ...testFiles];
+ const result = spawnSync("pnpm", args, {
+ cwd: ROOT,
+ encoding: "utf-8",
+ env: process.env,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return {
+ exitCode: result.status ?? 1,
+ stdout: result.stdout ?? "",
+ stderr: result.stderr ?? "",
+ };
+}
+
+/**
+ * @param {import("./config.mjs").MutationTarget} target
+ * @param {ReturnType<typeof parseArgs>} options
+ * @returns {MutantResult[]}
+ */
+function mutateTarget(target, options) {
+ const sourcePath = path.join(ROOT, target.source);
+ const original = fs.readFileSync(sourcePath, "utf-8");
+ let mutants = generateMutants(original, target.source);
+
+ if (options.maxMutantsPerFile != null) {
+ mutants = mutants.slice(0, options.maxMutantsPerFile);
+ }
+
+ if (options.dryRun) {
+ return mutants.map((mutant) => ({
+ id: mutant.id,
+ source: target.source,
+ operator: mutant.operator,
+ line: mutant.line,
+ column: mutant.column,
+ status: "skipped",
+ detail: `dry-run: ${mutant.original} -> ${mutant.mutated}`,
+ }));
+ }
+
+ /** @type {MutantResult[]} */
+ const results = [];
+
+ for (const mutant of mutants) {
+ fs.writeFileSync(sourcePath, mutant.content, "utf-8");
+ const run = runVitest(target.tests);
+ fs.writeFileSync(sourcePath, original, "utf-8");
+
+ if (run.exitCode !== 0) {
+ results.push({
+ id: mutant.id,
+ source: target.source,
+ operator: mutant.operator,
+ line: mutant.line,
+ column: mutant.column,
+ status: "killed",
+ });
+ continue;
+ }
+
+ results.push({
+ id: mutant.id,
+ source: target.source,
+ operator: mutant.operator,
+ line: mutant.line,
+ column: mutant.column,
+ status: "survived",
+ detail: `${mutant.original} -> ${mutant.mutated}`,
+ });
+ }
+
+ return results;
+}
+
+/**
+ * @param {MutantResult[]} results
+ */
+function summarize(results) {
+ const killed = results.filter((result) => result.status === "killed").length;
+ const survived = results.filter((result) => result.status === "survived").length;
+ const errors = results.filter((result) => result.status === "error").length;
+ const skipped = results.filter((result) => result.status === "skipped").length;
+ const scored = killed + survived;
+ const score = scored > 0 ? (killed / scored) * 100 : 0;
+
+ return { killed, survived, errors, skipped, scored, score };
+}
+
+function writeReport(reportPath, results, summary) {
+ const absolute = path.isAbsolute(reportPath) ? reportPath : path.join(ROOT, reportPath);
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ fs.writeFileSync(
+ absolute,
+ JSON.stringify(
+ {
+ tool: "meshmut",
+ generatedAt: new Date().toISOString(),
+ summary,
+ results,
+ },
+ null,
+ 2
+ ),
+ "utf-8"
+ );
+ return absolute;
+}
+
+function main() {
+ const options = parseArgs(process.argv.slice(2));
+ /** @type {MutantResult[]} */
+ const allResults = [];
+
+ for (const target of options.targets) {
+ process.stdout.write(`\nMutating ${target.source} (${target.tests.length} test file(s))\n`);
+ const results = mutateTarget(target, options);
+ allResults.push(...results);
+
+ const fileSummary = summarize(results);
+ process.stdout.write(
+ ` killed=${fileSummary.killed} survived=${fileSummary.survived} score=${fileSummary.score.toFixed(1)}%\n`
+ );
+ }
+
+ const summary = summarize(allResults);
+ const reportFile = writeReport(options.reportPath, allResults, summary);
+
+ process.stdout.write("\nMutation summary\n");
+ process.stdout.write(` killed: ${summary.killed}\n`);
+ process.stdout.write(` survived: ${summary.survived}\n`);
+ process.stdout.write(` score: ${summary.score.toFixed(1)}%\n`);
+ process.stdout.write(` report: ${reportFile}\n`);
+
+ if (summary.survived > 0) {
+ process.stdout.write("\nSurviving mutants:\n");
+ for (const result of allResults.filter((entry) => entry.status === "survived").slice(0, 20)) {
+ process.stdout.write(
+ ` ${result.source}:${result.line}:${result.column} [${result.operator}] ${result.detail ?? ""}\n`
+ );
+ }
+ if (summary.survived > 20) {
+ process.stdout.write(` ... and ${summary.survived - 20} more (see report)\n`);
+ }
+ }
+
+ if (options.minScore != null && summary.score < options.minScore) {
+ process.stderr.write(`\nMutation score ${summary.score.toFixed(1)}% is below minimum ${options.minScore}%.\n`);
+ process.exit(1);
+ }
+
+ if (summary.scored === 0) {
+ process.stderr.write("\nNo mutants were scored.\n");
+ process.exit(1);
+ }
+}
+
+main();


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────